Acceso a bases de datos con JDBC: tutorial completo Java 21

JDBC lleva con nosotros desde Java 1.1 y sigue siendo la capa sobre la que se construye prácticamente todo lo demás: Hibernate, Spring Data JPA, jOOQ... todos acaban hablando con la base de datos a través de JDBC. Entender cómo funciona de verdad te evita muchos dolores de cabeza cuando algo falla en producción y no sabes dónde mirar.

Este tutorial cubre el camino completo con Java 21: desde una conexión básica hasta tests de integración con H2 y migraciones de esquema con Flyway. Todo con código real que puedes copiar y adaptar.

1. Qué es JDBC y cuándo usarlo directamente

JDBC (Java Database Connectivity) es la API estándar de Java para acceder a bases de datos relacionales. Define interfaces como Connection, Statement, PreparedStatement y ResultSet, y cada fabricante de base de datos proporciona su propio driver que las implementa.

¿Cuándo usar JDBC en lugar de Hibernate o Spring Data? Cuando necesitas control total sobre las consultas, cuando trabajas con lógica SQL compleja que un ORM generaría mal, o cuando el rendimiento es crítico y no puedes permitirte el overhead del mapeo objeto-relacional. En proyectos pequeños también tiene sentido: añadir un ORM solo para cuatro tablas es matar moscas a cañonazos.

Para arrancar, añade el driver de tu base de datos en el pom.xml. Para PostgreSQL:

<dependency>
    <groupId>org.postgresql</groupId>
    <artifactId>postgresql</artifactId>
    <version>42.7.3</version>
</dependency>

Para MySQL:

<dependency>
    <groupId>com.mysql</groupId>
    <artifactId>mysql-connector-j</artifactId>
    <version>9.0.0</version>
</dependency>

2. Conexión directa y pool con HikariCP

La forma más básica de conectarse es con DriverManager.getConnection():

String url = "jdbc:postgresql://localhost:5432/miapp";
String user = "postgres";
String password = "secreto";

try (Connection conn = DriverManager.getConnection(url, user, password)) {
    System.out.println("Conectado: " + conn.getMetaData().getDatabaseProductName());
}

Funciona, pero no lo uses en producción. Abrir una conexión a la base de datos es caro: implica un handshake TCP, autenticación y negociación de parámetros. Si cada petición HTTP abre y cierra su propia conexión, la aplicación se cae bajo carga.

La solución es un pool de conexiones: un conjunto de conexiones ya abiertas que se reutilizan. HikariCP es el más rápido y el que Spring Boot usa por defecto. Añádelo al pom.xml:

<dependency>
    <groupId>com.zaxxer</groupId>
    <artifactId>HikariCP</artifactId>
    <version>5.1.0</version>
</dependency>

Configuración básica apuntando a PostgreSQL:

import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import javax.sql.DataSource;

public class DatabaseConfig {

    public static DataSource crearDataSource() {
        HikariConfig config = new HikariConfig();
        config.setJdbcUrl("jdbc:postgresql://localhost:5432/miapp");
        config.setUsername("postgres");
        config.setPassword("secreto");
        config.setMaximumPoolSize(10);          // máximo 10 conexiones simultáneas
        config.setConnectionTimeout(30_000);    // 30 segundos de espera máxima
        config.setIdleTimeout(600_000);         // cierra conexiones inactivas tras 10 min
        config.setMaxLifetime(1_800_000);       // recicla conexiones cada 30 min

        return new HikariDataSource(config);
    }
}

A partir de aquí, en lugar de llamar a DriverManager, pides conexiones al DataSource:

try (Connection conn = dataSource.getConnection()) {
    // trabaja con la conexión
} // se devuelve automáticamente al pool al salir del try

3. Statement vs PreparedStatement

Statement sirve para consultas fijas sin parámetros de usuario. Nada más. Si metes texto del usuario directamente en una cadena SQL, abres la puerta a SQL injection, uno de los ataques más comunes y devastadores.

// MAL: nunca hagas esto
String nombre = request.getParameter("nombre"); // podría ser "'; DROP TABLE usuarios; --"
Statement st = conn.createStatement();
ResultSet rs = st.executeQuery("SELECT * FROM usuarios WHERE nombre = '" + nombre + "'");

Usa siempre PreparedStatement cuando haya parámetros. El driver envía la consulta y los parámetros por separado, así que la inyección es imposible:

// INSERT con PreparedStatement
String sql = "INSERT INTO usuarios (nombre, email, edad) VALUES (?, ?, ?)";
try (PreparedStatement ps = conn.prepareStatement(sql)) {
    ps.setString(1, "Ana García");
    ps.setString(2, "[email protected]");
    ps.setInt(3, 28);
    int filasInsertadas = ps.executeUpdate();
    System.out.println("Filas insertadas: " + filasInsertadas);
}
// SELECT con PreparedStatement y recorrido de ResultSet
String sql = "SELECT id, nombre, email, fecha_alta FROM usuarios WHERE edad > ?";
try (PreparedStatement ps = conn.prepareStatement(sql)) {
    ps.setInt(1, 18);
    try (ResultSet rs = ps.executeQuery()) {
        while (rs.next()) {
            long id            = rs.getLong("id");
            String nombre      = rs.getString("nombre");
            String email       = rs.getString("email");
            Timestamp fechaAlta = rs.getTimestamp("fecha_alta");
            System.out.printf("  %d | %s | %s | %s%n", id, nombre, email, fechaAlta);
        }
    }
}

El ResultSet empieza antes de la primera fila. Cada llamada a next() avanza una posición y devuelve false cuando se acaban los registros. Los getters aceptan tanto el nombre de la columna como su posición (empezando en 1); el nombre es más legible y menos frágil si cambias el orden de las columnas.

4. Transacciones

Por defecto, JDBC trabaja en modo autocommit: cada sentencia se confirma automáticamente. Para operaciones que deben ejecutarse juntas o no ejecutarse en absoluto, desactivas el autocommit y gestionas tú el commit y el rollback.

El ejemplo clásico: una transferencia bancaria. Si el cargo en una cuenta se hace pero el abono en la otra falla, el sistema queda en un estado corrupto. Con una transacción, o las dos operaciones se confirman o ninguna:

public void transferir(DataSource ds, long cuentaOrigen, long cuentaDestino, BigDecimal importe)
        throws SQLException {

    try (Connection conn = ds.getConnection()) {
        conn.setAutoCommit(false);
        try {
            // Debitar cuenta origen
            try (PreparedStatement ps = conn.prepareStatement(
                    "UPDATE cuentas SET saldo = saldo - ? WHERE id = ?")) {
                ps.setBigDecimal(1, importe);
                ps.setLong(2, cuentaOrigen);
                int filas = ps.executeUpdate();
                if (filas == 0) throw new SQLException("Cuenta origen no encontrada: " + cuentaOrigen);
            }

            // Abonar cuenta destino
            try (PreparedStatement ps = conn.prepareStatement(
                    "UPDATE cuentas SET saldo = saldo + ? WHERE id = ?")) {
                ps.setBigDecimal(1, importe);
                ps.setLong(2, cuentaDestino);
                int filas = ps.executeUpdate();
                if (filas == 0) throw new SQLException("Cuenta destino no encontrada: " + cuentaDestino);
            }

            conn.commit(); // todo ha ido bien, confirmar
            System.out.println("Transferencia completada.");

        } catch (SQLException e) {
            conn.rollback(); // algo ha fallado, deshacer
            throw e;
        }
    }
}

Respecto a los niveles de aislamiento, los dos más usados en la práctica son READ_COMMITTED (solo ves datos confirmados por otras transacciones, es el nivel por defecto en PostgreSQL y MySQL) y REPEATABLE_READ (garantiza que si lees la misma fila dos veces dentro de una transacción, obtienes el mismo valor). Puedes fijar el nivel con conn.setTransactionIsolation(Connection.TRANSACTION_READ_COMMITTED).

5. Operaciones batch

Cuando necesitas insertar miles de registros, hacerlo fila a fila es muy lento: cada executeUpdate() implica un viaje de ida y vuelta a la base de datos. El batch agrupa varias operaciones y las envía juntas.

public void insertarProductosBatch(Connection conn, List<Producto> productos) throws SQLException {
    String sql = "INSERT INTO productos (sku, nombre, precio) VALUES (?, ?, ?)";

    conn.setAutoCommit(false);
    try (PreparedStatement ps = conn.prepareStatement(sql)) {
        int contador = 0;
        for (Producto p : productos) {
            ps.setString(1, p.getSku());
            ps.setString(2, p.getNombre());
            ps.setBigDecimal(3, p.getPrecio());
            ps.addBatch();

            // Enviar en lotes de 500 para no saturar la memoria
            if (++contador % 500 == 0) {
                ps.executeBatch();
                ps.clearBatch();
            }
        }
        ps.executeBatch(); // enviar los que queden
        conn.commit();
        System.out.println("Insertados " + productos.size() + " productos.");
    } catch (SQLException e) {
        conn.rollback();
        throw e;
    }
}

La diferencia de rendimiento es notable. En una prueba típica con 10.000 registros en PostgreSQL local, insertar uno a uno ronda los 8-10 segundos. Con batch de 500 baja a menos de 500 milisegundos. En un entorno con latencia de red entre la aplicación y la base de datos la diferencia es todavía mayor.

El batch tiene sentido en imports masivos, procesos ETL o carga inicial de datos. Para operaciones normales de una sola fila, no aporta nada.

6. Patrón DAO

Meter el código JDBC directamente en la lógica de negocio es un error que cobra factura rápido: los tests son difíciles de escribir, cambiar la base de datos implica tocar medio proyecto y el código se enreda. El patrón DAO (Data Access Object) separa completamente el acceso a datos del resto.

Defines una interfaz con las operaciones que necesitas:

public interface UsuarioDAO {
    Optional<Usuario> findById(long id);
    List<Usuario> findAll();
    void save(Usuario usuario);
    void update(Usuario usuario);
    void delete(long id);
}

Y una implementación con JDBC:

public class UsuarioDAOJdbc implements UsuarioDAO {

    private final DataSource dataSource;

    public UsuarioDAOJdbc(DataSource dataSource) {
        this.dataSource = dataSource;
    }

    @Override
    public Optional<Usuario> findById(long id) {
        String sql = "SELECT id, nombre, email, fecha_alta FROM usuarios WHERE id = ?";
        try (Connection conn = dataSource.getConnection();
             PreparedStatement ps = conn.prepareStatement(sql)) {
            ps.setLong(1, id);
            try (ResultSet rs = ps.executeQuery()) {
                if (rs.next()) {
                    return Optional.of(mapear(rs));
                }
                return Optional.empty();
            }
        } catch (SQLException e) {
            throw new RuntimeException("Error al buscar usuario con id " + id, e);
        }
    }

    @Override
    public void save(Usuario usuario) {
        String sql = "INSERT INTO usuarios (nombre, email, fecha_alta) VALUES (?, ?, ?)";
        try (Connection conn = dataSource.getConnection();
             PreparedStatement ps = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) {
            ps.setString(1, usuario.getNombre());
            ps.setString(2, usuario.getEmail());
            ps.setTimestamp(3, Timestamp.valueOf(usuario.getFechaAlta()));
            ps.executeUpdate();
            try (ResultSet keys = ps.getGeneratedKeys()) {
                if (keys.next()) {
                    usuario.setId(keys.getLong(1));
                }
            }
        } catch (SQLException e) {
            throw new RuntimeException("Error al guardar usuario", e);
        }
    }

    private Usuario mapear(ResultSet rs) throws SQLException {
        Usuario u = new Usuario();
        u.setId(rs.getLong("id"));
        u.setNombre(rs.getString("nombre"));
        u.setEmail(rs.getString("email"));
        u.setFechaAlta(rs.getTimestamp("fecha_alta").toLocalDateTime());
        return u;
    }

    // update() y delete() siguen el mismo esquema
}

Con esta estructura, el servicio de negocio depende de UsuarioDAO, no de UsuarioDAOJdbc. Si mañana quieres usar MongoDB o un mock en los tests, cambias la implementación sin tocar nada más. Para más sobre este y otros patrones de diseño en Java, puedes consultar nuestra guía sobre patrones de diseño en Java, incluido el patrón DAO.

7. Tests de integración con H2

H2 es una base de datos relacional escrita en Java que puede correr en memoria. Es perfecta para tests: arranca en milisegundos, no necesita instalación externa y desaparece cuando termina el test.

Añade la dependencia con scope test:

<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <version>2.3.232</version>
    <scope>test</scope>
</dependency>

Un test de integración para UsuarioDAOJdbc:

import org.junit.jupiter.api.*;
import com.zaxxer.hikari.*;
import javax.sql.DataSource;
import java.sql.*;

class UsuarioDAOJdbcTest {

    private static DataSource dataSource;
    private UsuarioDAOJdbc dao;

    @BeforeAll
    static void configurarDataSource() {
        HikariConfig config = new HikariConfig();
        config.setJdbcUrl("jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1");
        config.setUsername("sa");
        config.setPassword("");
        dataSource = new HikariDataSource(config);
    }

    @BeforeEach
    void crearEsquema() throws SQLException {
        try (Connection conn = dataSource.getConnection();
             Statement st = conn.createStatement()) {
            st.execute("""
                CREATE TABLE IF NOT EXISTS usuarios (
                    id         BIGINT AUTO_INCREMENT PRIMARY KEY,
                    nombre     VARCHAR(100) NOT NULL,
                    email      VARCHAR(200) NOT NULL,
                    fecha_alta TIMESTAMP    NOT NULL
                )
            """);
        }
        dao = new UsuarioDAOJdbc(dataSource);
    }

    @AfterEach
    void limpiarTabla() throws SQLException {
        try (Connection conn = dataSource.getConnection();
             Statement st = conn.createStatement()) {
            st.execute("DELETE FROM usuarios");
        }
    }

    @Test
    void guardarYRecuperarUsuario() {
        Usuario usuario = new Usuario();
        usuario.setNombre("Carlos López");
        usuario.setEmail("[email protected]");
        usuario.setFechaAlta(java.time.LocalDateTime.now());

        dao.save(usuario);
        Assertions.assertNotNull(usuario.getId(), "El id debe haberse asignado tras el INSERT");

        var recuperado = dao.findById(usuario.getId());
        Assertions.assertTrue(recuperado.isPresent());
        Assertions.assertEquals("Carlos López", recuperado.get().getNombre());
    }
}

La URL jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1 mantiene la base de datos en memoria mientras el pool tenga conexiones abiertas. Sin DB_CLOSE_DELAY=-1, H2 cierra la base de datos cuando se cierra la primera conexión y el pool falla al intentar abrir otra.

8. Migración de esquema con Flyway

El esquema de la base de datos cambia con el tiempo: añades columnas, creas índices, renombras tablas. Sin un control de versiones del esquema, coordinar esos cambios entre el entorno de desarrollo, staging y producción es un caos. Flyway resuelve esto con scripts SQL versionados.

Añade la dependencia:

<dependency>
    <groupId>org.flywaydb</groupId>
    <artifactId>flyway-core</artifactId>
    <version>10.15.0</version>
</dependency>

Crea los scripts en src/main/resources/db/migration/. El nombre sigue el patrón V{versión}__{descripción}.sql:

-- V1__create_usuarios.sql
CREATE TABLE usuarios (
    id         BIGSERIAL    PRIMARY KEY,
    nombre     VARCHAR(100) NOT NULL,
    email      VARCHAR(200) NOT NULL UNIQUE,
    fecha_alta TIMESTAMP    NOT NULL DEFAULT NOW()
);

-- V2__add_telefono_to_usuarios.sql
ALTER TABLE usuarios ADD COLUMN telefono VARCHAR(20);

Al arrancar la aplicación, ejecutas Flyway antes de cualquier otra operación:

import org.flywaydb.core.Flyway;

DataSource dataSource = DatabaseConfig.crearDataSource();

Flyway flyway = Flyway.configure()
    .dataSource(dataSource)
    .locations("classpath:db/migration")
    .load();

flyway.migrate(); // aplica los scripts pendientes

Flyway mantiene una tabla flyway_schema_history con el historial de scripts ejecutados. Si ya ejecutó V1, no lo vuelve a ejecutar. Si hay un V3 nuevo, lo aplica. Puedes ver el estado con flyway.info() y volver atrás con flyway.undo() (en la versión de pago). Más detalles sobre cómo integrar esto en una aplicación Spring Boot en nuestro artículo sobre migraciones con Flyway en Spring Boot.

9. Metadatos: DatabaseMetaData y ResultSetMetaData

A veces necesitas inspeccionar la estructura de la base de datos en tiempo de ejecución: qué tablas existen, qué columnas tiene una tabla, qué tipos soporta el motor. Para eso existe DatabaseMetaData:

try (Connection conn = dataSource.getConnection()) {
    DatabaseMetaData meta = conn.getMetaData();

    System.out.println("Motor: " + meta.getDatabaseProductName());
    System.out.println("Versión: " + meta.getDatabaseProductVersion());

    // Listar tablas del esquema actual
    try (ResultSet tablas = meta.getTables(null, null, "%", new String[]{"TABLE"})) {
        while (tablas.next()) {
            System.out.println("Tabla: " + tablas.getString("TABLE_NAME"));
        }
    }
}

ResultSetMetaData es útil cuando no sabes de antemano qué columnas va a devolver una consulta, por ejemplo en una herramienta genérica que ejecuta SQL arbitrario:

String sql = "SELECT * FROM usuarios LIMIT 5";
try (Connection conn = dataSource.getConnection();
     PreparedStatement ps = conn.prepareStatement(sql);
     ResultSet rs = ps.executeQuery()) {

    ResultSetMetaData rsMeta = rs.getMetaData();
    int numColumnas = rsMeta.getColumnCount();

    // Imprimir cabeceras
    for (int i = 1; i <= numColumnas; i++) {
        System.out.printf("%-20s", rsMeta.getColumnName(i));
    }
    System.out.println();

    // Imprimir filas
    while (rs.next()) {
        for (int i = 1; i <= numColumnas; i++) {
            System.out.printf("%-20s", rs.getString(i));
        }
        System.out.println();
    }
}

No es algo que uses en el día a día, pero viene bien para herramientas de administración, generadores de código o cualquier componente que necesite trabajar con esquemas desconocidos.

Conclusión

JDBC no es la opción más cómoda para proyectos grandes, pero saber cómo funciona marca la diferencia cuando depuras un problema de rendimiento en Hibernate o cuando entiendes por qué tu pool se queda sin conexiones a las tres de la mañana. Con HikariCP para la gestión de conexiones, PreparedStatement para las consultas, transacciones donde el negocio lo requiere, el patrón DAO para mantener el código limpio y Flyway para no perder el control del esquema, tienes todo lo que necesitas para un acceso a datos sólido.

La referencia oficial de la API está en la documentación de Java 21.

Imagen: Pexels

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP
ARTÍCULO ANTERIOR